package org.activiti.rest.api.jpa;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.util.ISO8601DateFormat;
import junit.framework.AssertionFailedError;
import org.activiti.engine.*;
import org.activiti.engine.identity.Group;
import org.activiti.engine.identity.User;
import org.activiti.engine.impl.ProcessEngineImpl;
import org.activiti.engine.impl.cfg.ProcessEngineConfigurationImpl;
import org.activiti.engine.impl.db.DbSqlSession;
import org.activiti.engine.impl.interceptor.Command;
import org.activiti.engine.impl.interceptor.CommandContext;
import org.activiti.engine.impl.interceptor.CommandExecutor;
import org.activiti.engine.impl.jobexecutor.JobExecutor;
import org.activiti.engine.impl.test.PvmTestCase;
import org.activiti.engine.impl.test.TestHelper;
import org.activiti.engine.runtime.ProcessInstance;
import org.activiti.rest.JPAWebConfigurer;
import org.activiti.rest.api.jpa.repository.MessageRepository;
import org.activiti.rest.conf.JPAApplicationConfiguration;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicHeader;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.session.HashSessionIdManager;
import org.eclipse.jetty.server.session.HashSessionManager;
import org.eclipse.jetty.server.session.SessionHandler;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;
import org.junit.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.*;
import java.util.concurrent.Callable;

public class BaseJPARestTestCase extends PvmTestCase {

    protected static final int HTTP_SERVER_PORT = 7979;
    protected static final String SERVER_URL_PREFIX = "http://localhost:7979/service/";
    protected static final List<String> TABLENAMES_EXCLUDED_FROM_DB_CLEAN_CHECK = Arrays.asList(
            "ACT_GE_PROPERTY"
    );
    protected static Server server;
    protected static ApplicationContext appContext;
    protected static ProcessEngine processEngine;
    protected static ProcessEngineConfigurationImpl processEngineConfiguration;
    protected static RepositoryService repositoryService;
    protected static RuntimeService runtimeService;
    protected static TaskService taskService;
    protected static FormService formService;
    protected static HistoryService historyService;
    protected static IdentityService identityService;
    protected static ManagementService managementService;
    protected static MessageRepository messageRepository;
    private static Logger log = LoggerFactory.getLogger(BaseJPARestTestCase.class);

    static {
        createAndStartServer();

        // Lookup services
        processEngine = appContext.getBean("processEngine", ProcessEngine.class);
        processEngineConfiguration = appContext.getBean(ProcessEngineConfigurationImpl.class);
        repositoryService = appContext.getBean(RepositoryService.class);
        runtimeService = appContext.getBean(RuntimeService.class);
        taskService = appContext.getBean(TaskService.class);
        formService = appContext.getBean(FormService.class);
        historyService = appContext.getBean(HistoryService.class);
        identityService = appContext.getBean(IdentityService.class);
        managementService = appContext.getBean(ManagementService.class);

        messageRepository = appContext.getBean(MessageRepository.class);

        Runtime.getRuntime().addShutdownHook(new Thread() {

            @Override
            public void run() {
                if (server != null && server.isRunning()) {
                    try {
                        server.stop();
                    } catch (Exception e) {
                        log.error("Error stopping server", e);
                    }
                }
            }
        });
    }

    protected ObjectMapper objectMapper = new ObjectMapper();
    protected String deploymentId;
    protected Throwable exception;
    protected ISO8601DateFormat dateFormat = new ISO8601DateFormat();

    public static void createAndStartServer() {
        server = new Server(HTTP_SERVER_PORT);

        HashSessionIdManager idmanager = new HashSessionIdManager();
        server.setSessionIdManager(idmanager);

        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
        applicationContext.register(JPAApplicationConfiguration.class);
        applicationContext.refresh();

        appContext = applicationContext;

        try {
            server.setHandler(getServletContextHandler(applicationContext));
            server.start();
        } catch (Exception e) {
            log.error("Error starting server", e);
        }
    }

    private static ServletContextHandler getServletContextHandler(AnnotationConfigWebApplicationContext context) throws IOException {
        ServletContextHandler contextHandler = new ServletContextHandler();
        JPAWebConfigurer configurer = new JPAWebConfigurer();
        configurer.setContext(context);
        contextHandler.addEventListener(configurer);

        // Create the SessionHandler (wrapper) to handle the sessions
        HashSessionManager manager = new HashSessionManager();
        SessionHandler sessions = new SessionHandler(manager);
        contextHandler.setHandler(sessions);

        return contextHandler;
    }

    @Override
    public void runBare() throws Throwable {
        createUsers();

        log.error(EMPTY_LINE);

        try {

            deploymentId = TestHelper.annotationDeploymentSetUp(processEngine, getClass(), getName());

            super.runBare();

        } catch (AssertionFailedError e) {
            log.error(EMPTY_LINE);
            log.error("ASSERTION FAILED: {}", e, e);
            exception = e;
            throw e;

        } catch (Throwable e) {
            log.error(EMPTY_LINE);
            log.error("EXCEPTION: {}", e, e);
            exception = e;
            throw e;

        } finally {
            TestHelper.annotationDeploymentTearDown(processEngine, deploymentId, getClass(), getName());
            dropUsers();
            assertAndEnsureCleanDb();
            processEngineConfiguration.getClock().reset();
        }
    }

    protected void createUsers() {
        User user = identityService.newUser("kermit");
        user.setFirstName("Kermit");
        user.setLastName("the Frog");
        user.setPassword("kermit");
        identityService.saveUser(user);

        Group group = identityService.newGroup("admin");
        group.setName("Administrators");
        identityService.saveGroup(group);

        identityService.createMembership(user.getId(), group.getId());
    }

    public HttpResponse executeHttpRequest(HttpUriRequest request, int expectedStatusCode) {
        CredentialsProvider provider = new BasicCredentialsProvider();
        UsernamePasswordCredentials credentials = new UsernamePasswordCredentials("kermit", "kermit");
        provider.setCredentials(AuthScope.ANY, credentials);
        HttpClient client = HttpClientBuilder.create().setDefaultCredentialsProvider(provider).build();
        try {
            if (request.getFirstHeader(HttpHeaders.CONTENT_TYPE) == null) {
                // Revert to default content-type
                request.addHeader(new BasicHeader(HttpHeaders.CONTENT_TYPE, "application/json"));
            }
            HttpResponse response = client.execute(request);
            Assert.assertNotNull(response.getStatusLine());
            Assert.assertEquals(expectedStatusCode, response.getStatusLine().getStatusCode());
            return response;

        } catch (ClientProtocolException e) {
            Assert.fail(e.getMessage());
        } catch (IOException e) {
            Assert.fail(e.getMessage());
        }
        return null;
    }

    public HttpResponse executeBinaryHttpRequest(HttpUriRequest request, int expectedStatusCode) {
        CredentialsProvider provider = new BasicCredentialsProvider();
        UsernamePasswordCredentials credentials = new UsernamePasswordCredentials("kermit", "kermit");
        provider.setCredentials(AuthScope.ANY, credentials);
        HttpClient client = HttpClientBuilder.create().setDefaultCredentialsProvider(provider).build();
        try {
            HttpResponse response = client.execute(request);
            Assert.assertNotNull(response.getStatusLine());
            Assert.assertEquals(expectedStatusCode, response.getStatusLine().getStatusCode());
            return response;

        } catch (ClientProtocolException e) {
            Assert.fail(e.getMessage());
        } catch (IOException e) {
            Assert.fail(e.getMessage());
        }
        return null;
    }

    protected void dropUsers() {
        IdentityService identityService = processEngine.getIdentityService();

        identityService.deleteUser("kermit");
        identityService.deleteGroup("admin");
        identityService.deleteMembership("kermit", "admin");
    }

    /**
     * Each test is assumed to clean up all DB content it entered.
     * After a test method executed, this method scans all tables to see if the DB is completely clean.
     * It throws AssertionFailed in case the DB is not clean.
     * If the DB is not clean, it is cleaned by performing a create a drop.
     */
    protected void assertAndEnsureCleanDb() throws Throwable {
        log.debug("verifying that db is clean after test");
        Map<String, Long> tableCounts = managementService.getTableCount();
        StringBuilder outputMessage = new StringBuilder();
        for (String tableName : tableCounts.keySet()) {
            String tableNameWithoutPrefix = tableName.replace(processEngineConfiguration.getDatabaseTablePrefix(), "");
            if (!TABLENAMES_EXCLUDED_FROM_DB_CLEAN_CHECK.contains(tableNameWithoutPrefix)) {
                Long count = tableCounts.get(tableName);
                if (count != 0L) {
                    outputMessage.append("  " + tableName + ": " + count + " record(s) ");
                }
            }
        }
        if (outputMessage.length() > 0) {
            outputMessage.insert(0, "DB NOT CLEAN: \n");
            log.error(EMPTY_LINE);
            log.error(outputMessage.toString());

            log.info("dropping and recreating db");

            CommandExecutor commandExecutor = ((ProcessEngineImpl) processEngine).getProcessEngineConfiguration().getCommandExecutor();
            commandExecutor.execute(new Command<Object>() {
                public Object execute(CommandContext commandContext) {
                    DbSqlSession session = commandContext.getSession(DbSqlSession.class);
                    session.dbSchemaDrop();
                    session.dbSchemaCreate();
                    return null;
                }
            });

            if (exception != null) {
                throw exception;
            } else {
                Assert.fail(outputMessage.toString());
            }
        } else {
            log.info("database was clean");
        }
    }

    protected String encode(String string) {
        if (string != null) {
            try {
                return URLEncoder.encode(string, "UTF-8");
            } catch (UnsupportedEncodingException uee) {
                throw new IllegalStateException("JVM does not support UTF-8 encoding.", uee);
            }
        }
        return null;
    }

    public void assertProcessEnded(final String processInstanceId) {
        ProcessInstance processInstance = processEngine
                .getRuntimeService()
                .createProcessInstanceQuery()
                .processInstanceId(processInstanceId)
                .singleResult();

        if (processInstance != null) {
            throw new AssertionFailedError("Expected finished process instance '" + processInstanceId + "' but it was still in the db");
        }
    }

    public void waitForJobExecutorToProcessAllJobs(long maxMillisToWait, long intervalMillis) {
        JobExecutor jobExecutor = processEngineConfiguration.getJobExecutor();
        jobExecutor.start();

        try {
            Timer timer = new Timer();
            InteruptTask task = new InteruptTask(Thread.currentThread());
            timer.schedule(task, maxMillisToWait);
            boolean areJobsAvailable = true;
            try {
                while (areJobsAvailable && !task.isTimeLimitExceeded()) {
                    Thread.sleep(intervalMillis);
                    areJobsAvailable = areJobsAvailable();
                }
            } catch (InterruptedException e) {
            } finally {
                timer.cancel();
            }
            if (areJobsAvailable) {
                throw new ActivitiException("time limit of " + maxMillisToWait + " was exceeded");
            }

        } finally {
            jobExecutor.shutdown();
        }
    }

    public void waitForJobExecutorOnCondition(long maxMillisToWait, long intervalMillis, Callable<Boolean> condition) {
        JobExecutor jobExecutor = processEngineConfiguration.getJobExecutor();
        jobExecutor.start();

        try {
            Timer timer = new Timer();
            InteruptTask task = new InteruptTask(Thread.currentThread());
            timer.schedule(task, maxMillisToWait);
            boolean conditionIsViolated = true;
            try {
                while (conditionIsViolated) {
                    Thread.sleep(intervalMillis);
                    conditionIsViolated = !condition.call();
                }
            } catch (InterruptedException e) {
            } catch (Exception e) {
                throw new ActivitiException("Exception while waiting on condition: " + e.getMessage(), e);
            } finally {
                timer.cancel();
            }
            if (conditionIsViolated) {
                throw new ActivitiException("time limit of " + maxMillisToWait + " was exceeded");
            }

        } finally {
            jobExecutor.shutdown();
        }
    }

    public boolean areJobsAvailable() {
        return !managementService
                .createJobQuery()
                .executable()
                .list()
                .isEmpty();
    }

    /**
     * Checks if the returned "data" array (child-node of root-json node returned by invoking a GET on the given url)
     * contains entries with the given ID's.
     */
    protected void assertResultsPresentInDataResponse(String url, String... expectedResourceIds) throws JsonProcessingException, IOException {
        int numberOfResultsExpected = expectedResourceIds.length;

        // Do the actual call
        HttpResponse response = executeHttpRequest(new HttpGet(SERVER_URL_PREFIX + url), HttpStatus.SC_OK);

        // Check status and size
        JsonNode dataNode = objectMapper.readTree(response.getEntity().getContent()).get("data");
        assertEquals(numberOfResultsExpected, dataNode.size());

        // Check presence of ID's
        List<String> toBeFound = new ArrayList<String>(Arrays.asList(expectedResourceIds));
        Iterator<JsonNode> it = dataNode.iterator();
        while (it.hasNext()) {
            String id = it.next().get("id").textValue();
            toBeFound.remove(id);
        }
        assertTrue("Not all process-definitions have been found in result, missing: " + StringUtils.join(toBeFound, ", "), toBeFound.isEmpty());
    }

    /**
     * Checks if the returned "data" array (child-node of root-json node returned by invoking a POST on the given url)
     * contains entries with the given ID's.
     */
    protected void assertResultsPresentInPostDataResponse(String url, ObjectNode body, String... expectedResourceIds) throws JsonProcessingException, IOException {
        assertResultsPresentInPostDataResponseWithStatusCheck(url, body, HttpStatus.SC_OK, expectedResourceIds);
    }

    protected void assertResultsPresentInPostDataResponseWithStatusCheck(String url, ObjectNode body, int expectedStatusCode, String... expectedResourceIds) throws JsonProcessingException, IOException {
        int numberOfResultsExpected = 0;
        if (expectedResourceIds != null) {
            numberOfResultsExpected = expectedResourceIds.length;
        }

        // Do the actual call
        HttpPost post = new HttpPost(SERVER_URL_PREFIX + url);
        post.setEntity(new StringEntity(body.toString()));
        HttpResponse response = executeHttpRequest(post, expectedStatusCode);

        if (expectedStatusCode == HttpStatus.SC_OK) {
            // Check status and size
            JsonNode rootNode = objectMapper.readTree(response.getEntity().getContent());
            JsonNode dataNode = rootNode.get("data");
            assertEquals(numberOfResultsExpected, dataNode.size());

            // Check presence of ID's
            if (expectedResourceIds != null) {
                List<String> toBeFound = new ArrayList<String>(Arrays.asList(expectedResourceIds));
                Iterator<JsonNode> it = dataNode.iterator();
                while (it.hasNext()) {
                    String id = it.next().get("id").textValue();
                    toBeFound.remove(id);
                }
                assertTrue("Not all entries have been found in result, missing: " + StringUtils.join(toBeFound, ", "), toBeFound.isEmpty());
            }
        }
    }

    /**
     * Checks if the rest operation returns an error as expected
     */
    protected void assertErrorResult(String url, ObjectNode body, int statusCode) throws IOException {

        // Do the actual call
        HttpPost post = new HttpPost(SERVER_URL_PREFIX + url);
        post.setEntity(new StringEntity(body.toString()));
        executeHttpRequest(post, statusCode);
    }

    /**
     * Extract a date from the given string. Assertion fails when invalid date has been provided.
     */
    protected Date getDateFromISOString(String isoString) {
        DateTimeFormatter dateFormat = ISODateTimeFormat.dateTime();
        try {
            return dateFormat.parseDateTime(isoString).toDate();
        } catch (IllegalArgumentException iae) {
            fail("Illegal date provided: " + isoString);
            return null;
        }
    }

    protected String getISODateString(Date time) {
        return dateFormat.format(time);
    }

    private static class InteruptTask extends TimerTask {
        protected boolean timeLimitExceeded = false;
        protected Thread thread;

        public InteruptTask(Thread thread) {
            this.thread = thread;
        }

        public boolean isTimeLimitExceeded() {
            return timeLimitExceeded;
        }

        public void run() {
            timeLimitExceeded = true;
            thread.interrupt();
        }
    }
}
